當 CLI 命令需要與外部服務(例如 REST API)互動時,若將網路請求直接寫死在命令內部,命令就會與外部環境高度耦合。要讓 CLI 具備良好的可測試性,關鍵在於將命令邏輯與外部相依解耦。
以一個查詢使用者資料的 user <id> 命令為例,執行時會向遠端服務取得資料並輸出名稱:
$ go run . user 1
Evan
底層發送請求至 API endpoint GET /users/{id},接收回傳的 JSON 資料:
{
"id": "1",
"name": "Evan"
}
若命令直接建立 HTTP client 並連線至真實服務,測試就會受到網路延遲、遠端資料異動或伺服器斷線影響,也無法穩定驗證 API 回傳 500 錯誤或 Ctrl+C 取消連線時的處理邏輯。
這篇將透過介面(Interface)與依賴注入(Dependency Injection),把外部連線從命令中解耦,在單元測試時能自由替換底層實作。範例專案位於 cli-sample/cli-dependency-testing/。
在 cmd/user.go 中,我們不讓 user 命令直接建立 HTTP client,而是先宣告 UserClient 介面作為命令所需的資料來源:
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
type UserClient interface {
GetUser(ctx context.Context, id string) (User, error)
}
這個介面建立了命令與資料提供者之間的Interface:只要型別實作了相同的 GetUser 方法,就能傳給命令使用。

為了方便測試,建立 userOptions 儲存 UserClient、輸出目標 io.Writer 與解析後的 ID:
type userOptions struct {
Client UserClient
Out io.Writer
ID string
}
runUser 只透過 opts.Client.GetUser 取得使用者,再把名字寫到 opts.Out。不需要知道傳進來的是真正的 HTTP client 還是測試用的 Mock:
func runUser(ctx context.Context, opts *userOptions) error {
user, err := opts.Client.GetUser(ctx, opts.ID)
if err != nil {
return fmt.Errorf("get user %q: %w", opts.ID, err)
}
_, err = fmt.Fprintln(opts.Out, user.Name)
return err
}
newUserCmd 建立 Cobra 命令,接收 UserClient 介面與 out 輸出目標,並在 RunE 中將 CLI 接收到的 args 帶入 runUser 執行:
// newUserCmd 接收可替換的 UserClient 介面與輸出目標
func newUserCmd(client UserClient, out io.Writer) *cobra.Command {
opts := &userOptions{Client: client, Out: out}
return &cobra.Command{
Use: "user <id>",
Short: "Get a user from the configured REST API",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
// 將 CLI 傳入的參數設定給 opts,並帶入 command.Context 執行業務邏輯
opts.ID = args[0]
return runUser(command.Context(), opts)
},
}
}
透過這樣的分工,Cobra 只負責參數驗證與 CLI 命令設定,runUser 則專心處理「查詢使用者並寫回輸出」的業務邏輯。
接下來在 cmd/user_test.go 中建立實作 UserClient 介面的 mockUserClient。讓 mockUserClient 包含 Testify 的 mock.Mock,即可在 GetUser 內呼叫 m.Called(ctx, id) 記錄傳入參數並取得回傳值:
// mockUserClient 實作 UserClient 介面,透過 Testify mock 記錄呼叫與回傳值
type mockUserClient struct {
mock.Mock
}
func (m *mockUserClient) GetUser(ctx context.Context, id string) (User, error) {
// 記錄傳入的 context 與 id 參數
args := m.Called(ctx, id)
// 取得預先設定的第一個回傳值 User 與第二個回傳值 error
user, _ := args.Get(0).(User)
return user, args.Error(1)
}
接著在測試時,設定 mockUserClient 收到特定 ID 時的回傳值,並驗證 runUser 是否正確處理資料與格式化輸出:
func TestRunUser(t *testing.T) {
// 設定當 GetUser 收到 ID "1" 時,回傳使用者 Evan(第一個 context 參數使用 mock.Anything 忽略比對)
client := new(mockUserClient)
client.On("GetUser", mock.Anything, "1").
Return(User{ID: "1", Name: "Evan"}, nil).
Once()
var stdout bytes.Buffer
opts := &userOptions{Client: client, Out: &stdout, ID: "1"}
// 執行被測函式並驗證輸出內容
require.NoError(t, runUser(context.Background(), opts))
assert.Equal(t, "Evan\n", stdout.String())
// 驗證 GetUser 是否確實被呼叫且參數符合預期
client.AssertExpectations(t)
}
Once() 要求 GetUser 只能被呼叫一次,AssertExpectations 則確認測試結束前確實發生了這次呼叫。
使用者執行 user 1 時,遠端 API 可能因維護或過載回傳 500 Internal Server Error。client 會把這個狀態轉成 error,runUser 則要補上正在查詢的使用者 ID,讓最外層收到完整的錯誤訊息:
user, err := opts.Client.GetUser(ctx, opts.ID)
if err != nil {
return fmt.Errorf("get user %q: %w", opts.ID, err)
}
單元測試不需要等待遠端服務真的出錯。將 mock 設定成回傳 HTTP 500 對應的 error,就能確認 runUser 回傳的訊息包含 ID,而且失敗時不會把不完整的使用者資料寫到 stdout:
func TestRunUserReturnsClientError(t *testing.T) {
// 設定 mock 在收到 GetUser("1") 時模擬 HTTP 500 錯誤回傳
client := new(mockUserClient)
client.On("GetUser", mock.Anything, "1").
Return(User{}, errors.New("unexpected HTTP status 500 Internal Server Error")).
Once()
var stdout bytes.Buffer
opts := &userOptions{Client: client, Out: &stdout, ID: "1"}
// 執行被測函式
err := runUser(context.Background(), opts)
// 驗證錯誤訊息包含 ID 上下文與原始錯誤,且失敗時 stdout 未寫入資料
require.ErrorContains(t, err, `get user "1": unexpected HTTP status 500 Internal Server Error`)
assert.Empty(t, stdout.String())
client.AssertExpectations(t)
}
使用者在等待 API 回應時按下 Ctrl+C,傳給 Command 的 context 會被取消。GetUser 收到同一個 context,底層停止等待後回傳 context.Canceled。runUser 使用 %w 包裝錯誤,因此最外層仍能用 errors.Is 判斷取消原因:
func TestRunUserPreservesContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client := new(mockUserClient)
client.On("GetUser", ctx, "1").
Return(User{}, ctx.Err()).
Once()
var stdout bytes.Buffer
opts := &userOptions{Client: client, Out: &stdout, ID: "1"}
err := runUser(ctx, opts)
require.ErrorIs(t, err, context.Canceled)
assert.Empty(t, stdout.String())
client.AssertExpectations(t)
}
cmd/user_client.go 的 HTTPUserClient 實作 GetUser,透過 http.NewRequestWithContext 將 Command 的 context 放進 request,再使用 http.Client 發送 GET /users/{id},檢查 HTTP 狀態碼後將 JSON 解析為 User。
在 main.go 程式入口點,建立 HTTPUserClient 實例並傳給 root Command:
userClient := cmd.NewHTTPUserClient("http://localhost:8080", http.DefaultClient)
root := cmd.NewRootCmd(userClient, os.Stdout)
正式執行時傳入 *HTTPUserClient,單元測試時傳入 *mockUserClient。因為兩者皆實作 UserClient 介面,runUser 的核心邏輯不需要為了測試加入額外分支。

把外部 API 抽象成 UserClient 介面,主要帶來三個好處:
Ctrl+C)時的處理邏輯,不需要改動或模擬遠端 API 伺服器的錯誤狀態。runUser 專心處理資料與輸出格式,Cobra 處理參數與命令,HTTPUserClient 專心處理 HTTP 請求發送與 JSON 解析,各元件的單元測試各自獨立。這種設計的權衡是 Mock 只能驗證 runUser 內部的呼叫邏輯與錯誤處理,無法確保 HTTPUserClient 實作的 URL 路徑、Header 或 JSON 解析欄位是否真的與遠端 API 一致。外部 API 合約的正確性仍需留給整合測試或使用 testscript 搭配真實 API 進行驗證。
建立可維護的 CLI 測試體系時,關鍵在於針對不同層級選用適當的驗證方式:
io.Writer 控制 stdout/stderr 輸出,並透過介面(如 UserClient)與 Mock 物件隔絕外部 API、檔案系統或時間等相依,獲得毫秒級的執行速度與極端失敗情境的覆蓋率。SetArgs 與 Execute 直接測試 Cobra Command 樹,驗證參數解析、Subcommand 組合與 Flag 邏輯。testscript 執行真實編譯出的 Binary,從獨立 Process 驗證 Exit Code、環境變數與完整 CLI 執行流程。測試是 CLI 能持續修改、重構的基礎。把驗證依範圍分層,每次改動才能快速得到可靠的回饋,這也是 CLI 隨功能增加仍能維持品質的關鍵。